Send somebody a coworker, and grant it nothing on arrival - #299
Draft
jerelvelarde wants to merge 26 commits into
Draft
Send somebody a coworker, and grant it nothing on arrival#299jerelvelarde wants to merge 26 commits into
jerelvelarde wants to merge 26 commits into
Conversation
…te does on the trail
… and install it granting nothing
…tes never mount The routes were wired into createApp behind an optional dependency and the composition root never constructed one, so /api/templates answered 404 on a real server while every route test passed: the tests build the app directly and hand it the dependency the process never had. Assembled here rather than inside createApp because the installer needs the vault, the plugin store, the trail and this deployment's endpoint policy, and each of those already exists exactly once in this file. The endpoint policy is deliberately the same pair agentFetch is given: an address registered by an import must be held to exactly what an address registered through /api/agents is.
A connector id was checked for length and nothing else, so a template could file a real tool ref as one. With no tools under it the per-tool check that a ref belongs to its connector never runs, so `id: google-drive/read_file_content` parsed cleanly and arrived where the server and the profile screen both re-derive connector-from-tool by looking for a slash. The invisible-character refusal is now the Unicode property classes rather than an enumerated list, which had drifted nine blocks narrower than the sentence it defends and let the first sixteen variation selectors through. Lengths are measured in the units the edit form measures them in, so a template can no longer install a Bot its owner cannot then save.
The grant route read the ref and never the ledger row's status, so an ask the consent screen had described as "nothing will be granted and nothing will be written", and the profile as "there is nothing yet to grant", sat beside a button that wrote a live MCP grant. Two guards, and the overlap is load-bearing. The status guard refuses an ask that was unavailable when the person read it, because somebody connecting that server afterwards is not their consent. The live re-check refuses a ref whose server or tool is absent right now, because the stored status is a snapshot from resolve time and a grant for a server that does not exist is invisible on every screen until the day one appears under that id.
The endpoint check and the byte-identical guard behind `reuse` both had no test, so either could have been deleted and the suite would have stayed green. Two skills in one template can no longer plan into the same slug, an imported Bot keeps the avatar its template named, and reuse of a skill somebody else owns falls through to a suffix rather than quietly attaching theirs.
The audit write went to the pool while the caller held a transaction on a pooled connection, so every import needed two at once and enough concurrent ones would wait on each other.
…be refused Name and title were truncated on the consent screen and both are concatenated into the standing role the model is given, so a stranger could write two hundred characters into a field the reviewer saw forty of. Nothing model-visible is clipped now. A deployment in dry-run enforces nothing, so it gets the amber warning rather than the reassuring block. A key typed for one template is no longer carried to the next one's address. The audit page knows the template events, so a refused import reads as refused rather than as allowed.
bun test runs every file in one process, so a pool left open is held for the rest of the suite. Six new files opened one and never closed it, and past a certain point PostgreSQL stops handing out connections — which does not read as a connection limit, it reads as the run dying at whatever file happened to be next. On CI it died at a different one each time and printed no summary at all. Every other integration test here already closes. These now do too, after their cleanup rather than before it.
jerelvelarde
force-pushed
the
feat/bot-templates
branch
from
August 30, 2026 20:16
67a0d55 to
3c4993f
Compare
Happy DOM defaults to about:blank, whose origin is the string "null". Better Auth builds its base URL from window.location.origin when it is not given one, so the first file to pull in the auth client under a bare registration threw Invalid base URL: null while it was still being imported, taking its tests with it and reporting an unhandled error rather than a failure anybody could place. Locally auth-client.test.ts stubs a window with a real origin and, when it runs first, the client is already cached by the time a rendered test needs it. That is an ordering accident. On CI the order differs, eight tests never ran, and the suite reported a count nobody could reconcile.
The sign-in test assigns a window with a location and nothing else. That was harmless while nothing in the suite installed a DOM; the rendered tests here do, and overwriting a registered window leaves document defined and window.history undefined. app/src/router.tsx builds its history at module scope from exactly that pair, so it threw while being imported by whichever file reached it next, and took that file's tests with it.
…erstands A template says shell, files, browser, hosts and mcp in a closed vocabulary; this turns that into CEL the gateway evaluates like any other rule. A template never writes CEL, and could not: the vocabulary has no way to say anything but less. Three properties, each with a reason. The Bot is the LEADING conjunct because cel-js short-circuits and an unscoped clause that throws refuses every action for every Bot rather than for the one named. Host lists compile to equality and never to a pattern, because the matches helper throws on a bad pattern and a throwing deny counts as a match. And every clause is evaluated against ten neutral contexts before it is stored, through the same engine that will judge it, so a malformed one is refused rather than written. The emitter refuses a value it cannot write verbatim rather than escaping it: cel-js does not decode escape sequences, so an escaped literal would be a rule that silently never matches the host it names.
The clauses are written in the same transaction that creates the Bot, and composed into the policy the gateway reads rather than into the array an administrator edits. Never into action_policy.deny, and the reason is a lost update nobody would see: that array is replaced wholesale from a screen snapshot with no version column, so a clause stored there is erased the next time somebody saves an unrelated rule. Separate storage makes that impossible. Retracting an import lifts its ceiling the same way it takes back its grants.
The consent screen said a deployment does not yet enforce what a template declares. That was true and is not any more, so it says what it now does instead. The separate warning about a deployment whose policy still allows every action stays: a ceiling only ever subtracts, and a Bot held to browsing on a deployment that permits everything is still a Bot that can browse. Boundaries lists the generated clauses in a group of their own, grouped by the coworker, and offers no way to edit them there — they are not in the array that screen posts.
…nned to Two sources, and nothing is fetched from the network unless an administrator registers one. The directory in the image is read per file rather than as a package. The tenant loader refuses to boot on a malformed file, which is right for an operator's own configuration and wrong for a directory of many authors' work: one bad file names itself in the log and the rest still load. A git source is pinned to a commit, allowlisted by an environment variable that ships empty and that no screen can widen, and fetched server-side so the browser never acquires a third-party origin and the source never sees an end user's address. Moving the pin is the only update mechanism, it is an administrator's deliberate act, and it changes nothing already installed.
…wser Installing from the gallery re-reads the template server-side and refuses a digest that no longer names that entry, so what is installed is what the deployment holds rather than what was posted back to it. There is no way to publish into the gallery over HTTP: publishing is a git push.
The gallery lists what this deployment offers with the author rendered as a claim and no counts and no ratings — there is nothing to count and a count is a thing to forge. Admin gains a page listing every import, what it asked for, the ceiling it applied, and Retract.
bun test runs every file in one process, and parts of the component library decide at module scope whether they have a browser at all — a portal that concludes it has none stays switched off for the whole run. This file loads the app's entire route tree, so whichever order the suite was walked in, it decided that for everybody: the admin dialog tests failed here while the dialog opened correctly in a browser. The import is dynamic so the registration stays in front of it. The file already needed browser globals; it was getting them by accident from whichever rendering test happened to run first.
A values file written before this key existed must still render, and the chart proves that by rendering without each key added since v0.0.5. Emitted unconditionally it produced an environment variable with an empty value, which is not the same as an absent one: the server's own default resolves to this same directory, and an empty string is a deployment told to read templates from nowhere.
The boundary route answered with the composed policy, so a clause an import applied appeared in the list the screen edits, with a Remove button beside it. Saving filters those clauses back out, so the button succeeded, changed nothing, and left the rule enforced with nothing on screen saying why. A screen that offers an action it will not take is worse than one that does not offer it. Two readers now: get is what the engine enforces, authored is what a person wrote. The clauses are still on the same page, in the read-only group that explains where they came from. The test that covered this asserted the composed value, so it agreed with the bug rather than catching it. It now asserts the editable list is the operator's own.
Every template drew the same box icon, so a dozen of them were a dozen identical rows and the eye had nothing to land on. A card now carries the drawing the coworker will actually have — the same seed, so the card previews what importing produces — over a wash taken from that seed, which makes one template distinguishable from another before either name is legible. A grid and a wider shell, which is the one place this feature leaves the layout every other screen uses, and the reason is the same one the audit log has: this is the only browse surface in the product. Somebody is comparing coworkers they have never seen against each other, and at prose width that comparison is a scroll. WHAT WAS NOT TAKEN from the reference. No install count, no downloads, no stars, no rating, no featured badge, no price. They are most of what gives a marketplace its density and this one cannot draw a single one honestly: nothing here counts anything, there is no service to count on, and a number supplied by the author of the thing being judged is worse than no number. The space they would have taken goes to the claim, the summary, and what the template asks for. The card also survives a server that has not rolled yet: an absent avatar seed falls back to the slug rather than taking the page down, which is what reading .length off undefined did.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A coworker is the most valuable thing a person builds here, and it cannot leave the deployment it was
built in. The only way to give a colleague the Bot you spent a week on is to describe it and have
them do the week again, and across a fleet the same Bot is rebuilt by hand in staging and in
production until the two drift.
Two things almost do this and neither fits. The tenant package is the whole deployment — brand,
channels, model, roster — read from a directory at boot by whoever runs the process; it cannot
describe one coworker, and it is unsafe on a stranger's file, since
expandEnvironmentsubstitutes${NAME}out of the server's own environment before the YAML is parsed.duplicatecopies fourpresentation fields, drops the endpoint and every grant, and throws
ManagedAgentUnavailableErroroutright when the deployment has no Bot of its own — so on the one-container image the README
recommends, that button is dead.
The design is already written down in the code that needs it:
tenant-package.ts:137concludes askill is safe to seed because "a skill is an instruction and confers nothing", while an MCP grant is
not because "those reach a person's own account". Nothing used it.
The approach
A Bot template is one YAML file describing one coworker — identity and prose, the skills it
depends on, the capabilities it asks for, and a ceiling on what it may do. The vocabulary is the
tenant package's, so anyone who has read
examples/fintech/can read a template.Configuration travels; capability does not. No id, endpoint URL, credential, MCP grant, component
source or policy rule can travel, because none are fields: a document carrying one fails to parse and
is told which key and why. That beats stripping them silently — an author who tried to ship a key
learns, and a reviewer is not reading a redacted copy of something larger. Import writes exactly one
grant,
plugin_grantskind'skill', markedtemplate:<digest12>so a retraction takes back whatthe import gave and never an administrator's own. Everything else lands in a ledger as requested and
not granted, satisfied afterwards on the screens that already decide those things.
Three divergences from the package loader, each a security decision. Parsing is strict, because
an ignored key is one the reviewer's eye slid over and the parser agreed to. There is no environment
interpolation at all, and the check reads raw bytes so a comment cannot carry one either. Slugs use
the API's rule rather than the package's looser one, which admits
xandfind-— both installcleanly and are then permanently uneditable through the product.
The consent screen is the control, so it is built as one. The role description and every skill's
instructions render verbatim and unabridged, under the sentence that a stranger wrote them. Invisible
codepoints are a parse refusal rather than a rendering problem, because a review control that can be
made invisible is not a control.
An unmet ask never blocks the install, which is the decision most likely to be questioned.
Blocking would make "grant everything" the fastest route to a working Bot. The Bot arrives cold and
does not lie about it:
grantedToolGuidancebuilds its self-description from the tools it wasactually offered, so it says it has no source rather than claiming what the template promised.
The ceiling is enforced, not just disclosed. The
boundary:block compiles into CEL deny clausesscoped to one Bot. The Bot is the LEADING conjunct because cel-js short-circuits, and an unscoped
clause that throws would refuse every action for every Bot rather than the one named. Host lists
compile to equality, never a pattern, because
matchesthrows on a bad pattern and a throwing denycounts as a match. Every clause is evaluated against neutral contexts — through the engine that will
judge it — before it is stored.
They live in
template_boundaries, composed into what the engine reads and never written intoaction_policy.deny: that array is replaced wholesale from a screen snapshot with no version column,so a clause stored there would be erased by the next unrelated save. The screen is served what an
administrator wrote, with the generated clauses beside it, read-only.
A catalogue, and sources pinned to a commit. The image ships templates and reads them per file,
so one malformed file names itself in the log instead of stopping the deployment booting. Beyond that
an administrator may register a repository pinned to a sha, allowlisted by an environment variable
that ships empty and that no screen can widen, fetched server-side so no browser acquires a
third-party origin. Moving the pin is the only update mechanism.
What is not covered
an administrator pinned. A link that installs for a stranger would need a service somebody operates,
a moderation queue and a signing key; the reference catalogue is
jerelvelarde/awesome-openbot-templates,and its trust model is git's — whoever can merge there.
authoris a claim,rendered as one. Pin a commit you have read.
template.versionis a string nothing reads. Security fixes do notpropagate either — that is the trade, and auto-update has no safe form without publisher identity.
type: built_incannot travel, so exporting a shipped fintech Bot is not a faithful round trip.made — revocation goes through the existing screens, because the ledger stores the ask and a
decision, never a second source of truth for a permission.
Verification
format:check,lintandtypecheckclean.bun run test:cireports 2318 pass, 0 fail againsta baseline of 2149 on
main— 169 new tests.Driven end to end against a running deployment throughout, which is how several of these were found:
the routes were mounted behind an optional dependency the composition root never built, so
/api/templatesanswered 404 on a real server while every route test passed; the packer's ownsecret-scanner fixtures were shaped enough like credentials to be blocked by push protection; and the
boundary screen was served the composed policy, so a clause an import applied appeared in the list an
administrator edits, beside a Remove that saving filters back out. The test covering that last one
asserted the composed value, so it agreed with the bug rather than catching it.
Three more came from CI failing where a laptop could not, all of them the same shape:
bun testrunsevery file in one process, parts of the app decide at module scope whether they have a browser, and
whichever file loads the app graph first decides that for everybody. Six integration files held their
connection pool open; Happy DOM registers at
about:blank, whose origin is the string"null", sothe first file to reach
@/lib/auth/clientthrew while being imported; andauth-client.test.tsassigns a window with a
locationand nothing else, which leftwindow.historyundefined forapp/src/router.tsx. Two of those fixes touch files this feature does not otherwise go near; they arehere because these tests are what made them reachable.
shared/bot-template.test.tsserver/tests/template-pack.test.tsserver/tests/template-install.integration.test.tsserver/tests/template-routes.integration.test.tsserver/tests/template-boundary.test.tsserver/tests/template-boundary-enforcement.integration.test.tsserver/tests/template-catalogue.test.tsserver/tests/template-gallery-routes.integration.test.tsapp/tests/import-template.test.tsxA hostile review over the diff produced 27 candidate defects; 24 survived independent verification and
are fixed in the last five commits — including one where an ask both consent surfaces described as
inert sat beside a button that wrote a live MCP grant.